iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
生成式 AI

阿,又是一個RAG系列 第 24

Day23: RAG as workflow

  • 分享至 

  • xImage
  •  

Intro

  • 我們今天要來實作 RAG baseline,當然,用的是 llama-index 的 workflow

  • 如果你對 llama-index 的 workflow 不熟悉,可以:

  • 這是我們的 RAG baseline workflow 的長相:
    https://ithelp.ithome.com.tw/upload/images/20251008/20177855IqWxK5ZYqm.jpg

  • 圓圈是 event,放的是資料;方塊是 step,放的是處理步驟

  • 整體來說就兩條路徑:

  1. 從 StartEvent 開始 -> 走 ingest -> StopEvent
    • 做的是把文檔讀進來 -> 切成 node -> embedding -> 再存出去
  2. 從 StartEvent 開始 -> 走 retrieve -> rerank -> synthesize -> StopEvent
    • 就是查資料再回答

那我們就來看一下這個是怎麼做出來的吧

Action

1. Workflow 的設計

我們的 steps 包含:

  • ingest step: 用來建立本地文檔的 index

    • 我們會用 FaissVectorStore 來完成
  • retrieve step: 用來檢索文檔

    • 我們會用 VectorStoreIndex.as_retriever,它會幫我們處理 embedding 還有算 similarity 回傳 top_k 給我們
  • rerank step: 用來重排檢索到的文檔

    • 我們使用 llama-index 的 LLMRerank
    • 就是直接 prompt llm 做重新排序,功能包含:
      • 過濾不必要的 node
      • 給定 query, 替每個 node 打分
      • 最後我們再基於 llm 的打分完成 node 的重新排序
    • 這在步驟上算是 Node PostProcessor
  • synthesize step: 用來合成最後的回答

    • 我們用 llama-index 提供的 CompactAndRefine
    • 這個呼叫比較複雜,包含:
    • Token 預算切分
      • 根據 token 預算,把 node 合併成幾個較大的 chunk
    • Compact 初始合成:
      • 如果只切出一個 chunk,模型會直接基於這個 chunk 產生答案
      • 如果有多個 chunk,則會先使用第一個 chunk 來生成初步回答
    • Refine 逐步調整:
      • 之後依序引入剩下的 chunk,模型會在原本的答案基礎上逐步 refine,補充或修正答案,直到所有 chunk 都處理完畢。

2. 來看看各Event

StartEvent

  • 由 呼叫端 發出,ingest step 以及 retrieve step 都會接收
  • 有以下屬性:
    • document_dir, index_dir
    • query, index
  • 若給的是 index_dir 以及可選的 document_dir ,執行的就是第一條建index
  • 若給的是 query 以及 index,retrieve step ,執行的就是第二條: 查資料再回答

RetrieverEvent

  • 由 retrieve step 發出,synthesize step 接收
  • nodes 屬性,就是帶 Score 的文本塊

RerankEvent

  • 由 rerank step 發出
  • synthesize step 接收
  • 一樣是 nodes 屬性,就是帶 Score 的文本塊

StopEvent

  • ingest step 以及 synthesize step 都有可能發出 StopEvent
  • 屬性都是 result
    • 如果是 ingest step 發的,result 就是 index
    • 如果是 synthesize step 發的,result 包了我們想要的所有中間結果

3. Steps 的關鍵步

  • 接著我們逐 step 看關鍵步
  • 完整的程式碼在: 這裡

3.1 ingest

首先是建 index 然後存出去,這邊有三個角色:

  • FaissVectorStore
    • 最底層,負責真正儲存與檢索向量(embedding 後的數據)這裡是 Faiss。
  • StorageContext
    • 本質上是 llama-index 抽象出的一個管理介面,用來支援多種底層
    • 我們會用 StorageContext 把建好的資料庫存成檔案
  • VectorStoreIndex
    • 最上層的入口,負責把文件轉成向量並寫進 vector store
    • 同時也定義了後續查詢與檢索的流程
documents = SimpleDirectoryReader(document_dir).load_data()
d = 1536
faiss_index = faiss.IndexFlatL2(d)
vector_store = FaissVectorStore(faiss_index=faiss_index)
storage_context = StorageContext.from_defaults(
    vector_store=vector_store
)
index = VectorStoreIndex.from_documents(
    documents=documents,
    embed_model=OpenAIEmbedding(model_name="text-embedding-3-small"),
    storage_context=storage_context,
)
index.storage_context.persist(persist_dir=index_dir)
print("Index built and persisted to:", index_dir)

這邊可以與官方的最小可行範例做對比

  • 如果沒有指定 VectorStore 用的會是預設的 SimpleVectorStore 速度跟可擴展性會差一些
  • 此外因為我們不想要每次建 index 都要重新 embed,所以我們存成實體檔案
documents = SimpleDirectoryReader(dirname).load_data()
index = VectorStoreIndex.from_documents(
    documents=documents,
    embed_model=OpenAIEmbedding(model_name="text-embedding-3-small"),
)
  • 如果是 index 已經建好的情況(沒提供 document_dir),我們就直接 load 回來
vector_store = FaissVectorStore.from_persist_dir(index_dir)
storage_context = StorageContext.from_defaults(
    vector_store=vector_store, persist_dir=index_dir
)
index = load_index_from_storage(storage_context=storage_context)

3.2 retrieve

這邊要看兩個:

  • 一個是如果沒有給 query 屬性,會直接回傳 None,所以它就知道這次是建 index 的呼叫
  • 然後是把 index 轉成 retriever,並且 retrieve
query = ev.get("query")
index = ev.get("index")
if not query:
    return None
print(f"Query the database with: {query}")
retriever = index.as_retriever(similarity_top_k=SIM_TOP_K)
nodes = await retriever.aretrieve(query)
print(f"Retrieved {len(nodes)} nodes.")

3.3 rerank

  • Rerank 這邊可以指定一次 llm 的呼叫要處理多少個 node,就是它的choice_batch_size
  • top_n 用來選擇重排後要回傳多少個 node
ranker = LLMRerank(
    choice_batch_size=5, top_n=3, llm=OpenAI(model="gpt-4o-mini")
)

new_nodes = ranker.postprocess_nodes(
    ev.nodes, query_str=await ctx.store.get("query", default=None)
)
print(f"Reranked nodes to {len(new_nodes)}")
return RerankEvent(nodes=new_nodes)

針對 rerank 我們有進行一個小測試,完整的記錄在: 這裡

  • 首先是我們詢問 query=什麼是深度學習的醍醐味?
  • 答案在 retriever 檢索回來的 第 8 個 node
  • 而經過 reranker 重排後
    • 它刪去了其中的 5 個 node
    • 而原本的第 8 個 node 被排到了第 1
  • 但這邊也揭露了一個問題,雖然我們對 rerank 的結果感到滿意,但如果一開始的 retriever 沒有選到 top 8,而是常見的 top 5 rerank 將沒有用武之地

此外關於 rerank 的預設 prompt,可以在這裡 的 deep dive 找到 DEFAULT_CHOICE_SELECT_PROMPT_TMPL

  • 雖然在 prompt 有明確強調可以把不相關的 node 刪去
  • 但我們的測試中觀察到的是, llm 不太會把 node 刪除,頂多給一個低分,也就是上面的 8 刪 5 是後處理步驟做的事 (由 top_n)

3.4 synthesize

  • 這部分就兩行:
llm = OpenAI(model="gpt-4o-mini")
summarizer = CompactAndRefine(llm=llm, streaming=False, verbose=True)
  • synthesize 這邊乍看之下就只是 prompt llm 根據 context 回答
    • 預設的 prompt 可以在 這裡 synthesize 的部分找到
  • 不過在 LlamaIndex 裡,synthesize 的角色本質上是:如何把 retriever 回傳的多個 node → 組合成一個答案。
  • 這就涉及了多項不同的策略
  • llama-index 針對這部分提供了相當多的模式可以參考舉例來說:
  • 一個思路是 refine: 一個 node 我就呼叫一次 llm ,第二次以後的呼叫,llm的input 包含了原始問題、上一次的回答、新的 node,llm可以選擇要不要修改
  • 一個思路是 compact: 本質上跟 refine 是同一件事,但 refine 有多少 node 就會呼叫多少次 llm ,而 compact 則是會盡可能的合併 node,可以說 compact 就是 更少次 llm 呼叫的 refine
  • 一個思路是 tree_summarize,回傳的 node 會先被切塊,然後每一塊都被進行 summary,之後遞迴的 summary 所有的 summary 直到只剩下一個 node,這在總結大篇文章的時候很有用
  • accumulate: 先針對每一個 node 進行一次回答,然後最後拼接回傳所有的回答
  • 此外由於 synthesize 回傳的就是最後的 response 了,這邊的建議是可以在這步把前面想要留存的 output 結果 (比如 retrieve 了那些 node, rerank 之後又剩下哪些 node) 也一併取出來回傳 (中間先存在 ctx)

4. workflow run

import asyncio
async def main():
    # w = RAGWorkflow(); index = await w.run(index_dir=INDEX_DIR) ...
    ...

if __name__ == "__main__":
    asyncio.run(main())

Summary

  • 我們今天用 workflow 實作了 baseline RAG,並且包含了 rerank
  • 首先我們處理了 indexing 的步驟,介紹了
    • FaissVectorStore
    • StorageContext
    • VectorStoreIndex
  • Rerank 我們直接引入 llm 重排,並且在單一範例下效果拔擢
    • 同時認識到,若是 retriever 步驟就沒有查到,rerank 巧婦難為無米之炊
  • synthesize 的部分我們認知到了,它不是單純的 prompt llm 用 context 回答 query
    • 而是 給定 node 與 一堆 context,要怎麼組合出最後的回答
    • 中間涉及了細緻 vs. 效率、摘要 vs. 完整、單一答案 vs. 多答案的考量
  • 最後我們執行 workflow 把先前製造的問題跑出答案
  • 我們明天來用 evaluator 驗證看一下結果

其他

  • 終於寫到 RAG 了!

上一篇
Day22: Evaluating Semantic Similarity and P-R Curve
系列文
阿,又是一個RAG24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言